镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Stop failed logins from revealing which usernames exist - #439

Open
pacmano1 wants to merge 4 commits into
OpenIntegrationEngine:mainfrom
pacmano1:fix/username-enumeration-lockout
Open

pacmano1 wants to merge 4 commits into
OpenIntegrationEngine:mainfrom
pacmano1:fix/username-enumeration-lockout

Conversation

@pacmano1

@pacmano1 pacmano1 commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #240.

A failed login gave away whether the username was real, in two separate ways. This fixes both, and adds a server event so an admin can still see when an account locks.

The messages

The lockout paths in DefaultUserController.authorizeUser named the account and reported how long the lock had left to run, and a wrong password for a real account reported the attempts left before the lock. An unauthenticated caller could tell a valid username from an invalid one. A wrong password, an unknown username and a locked account now all return FAIL and Login failed.

Per @tonygermano, the message doesn't say "incorrect username or password", because for a locked account given the right password that isn't true. There is no property to bring the detailed messages back.

The lockout event

Per @tonygermano, the failed login that locks an account dispatches a User account locked server event, level WARNING, outcome FAILURE, with the username and the time it unlocks (locked until). Attempts against a locked account are rejected before a strike is counted, so there is one event per lock.

It is dispatched from DefaultUserController, so it covers HTTP basic auth on the API too, which goes through MirthServlet and logs no Login event at all. It carries no IP address, since the controller doesn't have one; the Login event logged for the same /users/_login request has it.

On a running engine with retrylimit = 30, the 31st wrong password logged one User account locked event with username and a locked until an hour later. The 32 attempts after it against the locked account, the right password included, logged no more. Basic auth on GET /api/users/current did the same, and there the lock event was the only event the 33 failed requests left.

The timing

Fixing the messages alone left a second and much larger signal in place. Closing #240 on the message fix by itself would have overstated what changed.

The password digest is only reached for a valid, unlocked user. An unknown username never enters the validUser != null block, and a locked account returns before it, so both skipped 600,000 PBKDF2 iterations. Measured with generic messages already enabled, 250 samples per cohort:

cohort median p10 p90
valid + unlocked 230.90 ms 227.52 234.27
valid + locked 0.49 ms 0.42 0.72
invalid username 0.76 ms 0.66 0.93

The distributions do not overlap. The fastest unlocked sample was still 240x slower than the slowest locked one, so one timed request classified any username whatever the response body said. An attacker could enumerate accounts without reading a message at all.

A same-account control confirms the cause. An account that crossed the strike limit partway through a run stepped from 232.03 ms to 0.33 ms at the boundary, with no samples in between. The account did not change, only its lockout state did.

Every path that skips the real digest now runs a throwaway one and discards the result: an unknown username, a locked account, an account that exists but has never had a password set, and a failed pre-2.2 hash check. The last two are reachable in normal use, since an admin can create an account before provisioning it and legacy hashes survive upgrades. The throwaway goes through matches() so the fallback digester runs the way it does on the real path, and the hash is generated once from the live digester so it follows the configured algorithm and iteration count. Measured again on the same rig by the same method:

cohort before after
valid + unlocked 230.90 ms 237.74 ms
valid + locked 0.49 ms 236.84 ms
invalid username 0.76 ms 237.05 ms
worst ratio 470x 1.00x

The no-password case was checked separately against a real account and came back at 234.16 ms against 233.89 for a valid user and 233.45 for an unknown one.

Rechecked on this commit on a slower machine, where one digest costs about 1.25 s, with 30 interleaved samples each: unknown username 1237.75 ms, valid + unlocked 1251.88 ms, valid + locked 1250.89 ms.

This is not constant time and I am not claiming it is. A valid user still costs one extra credentials query that a miss does not, which measured under a millisecond against a 237 ms floor. There is a ponytail: marker on the helper naming that ceiling and the condition for revisiting it.

There is a tradeoff here worth arguing with. Locked accounts are no longer cheap to reject, so lockout no longer caps the cost of repeatedly probing an account already known to exist. My reasoning is that an attacker gains nothing by locking an account once every username costs the same, so that cheapness was mostly helping the attacker. Tell me if you see it differently.

What this does not cover

An AuthorizationPlugin returning a non-null LoginStatus returns before any of this code, so OIDC, LDAP and similar plugins are unaffected in both directions: nothing here breaks them, and nothing here protects them. Their messages and timing are their own. A plugin that replaces UserController outright through ExtensionLoader would miss this entirely, and grepping this repo will not reveal one.

Left for their own issues: a failure reason on every failed Login event (the reason is known in the controller, but the Login event is built in the servlet from the same LoginStatus the caller receives), and lock status in the user screens plus a REST unlock.

Tests

DefaultUserControllerAuthorizeUserTest covers what a failed login returns and the event it dispatches: a locked account gets the generic failure and is indistinguishable from an unknown username, a wrong password for a real account is indistinguishable from an unknown username, and the attempt that locks an account dispatches one User account locked event while the attempts after it dispatch none. All four fail against current main and pass with this change.

It binds mock ControllerFactory and SqlConfig through Guice static injection, the way DefaultHttpConfigurationTest already does, so no database is involved. Lockout state is set directly on the User, because LoginRequirementsChecker reads the strike count and last strike time straight off it.

No test asserts the timing. Mocking the digester removes the cost being measured, and a real one makes every call take 230 ms and turns the suite into a flaky performance test. The numbers above come from a running server.

Not added: a smoke test in ci/. The new harness boots a real engine across seven configurations and has a Java seam with a live Client, so the message behaviour could be asserted there against all five databases. That is worth doing and I have not done it; it belongs in its own change rather than growing this one further. The timing assertion would still be a poor fit for shared runners.

Clean build, 685 tests, 0 failures.

Credit

This is @mgaffigan's fix from #241, reworked for the current source layout. That branch predates the June move to src/main/java and no longer applies. The timing half and the lockout event are new.

Test rename

PasswordRequirementsTests becomes PasswordRequirementsTest. The build includes **/*Test.class only, so the plural-named class has never run in any build. Six dormant cases now execute and pass. The dead *Tests problem is wider than this file and needs its own issue.

Behavior change

Every failed login now says Login failed., where it used to say Incorrect username or password. and, with lockout enabled, add the attempts left or the lock and its remaining time. Anything scraping the message string will notice, and a locked-out user is no longer told they are locked; the admin sees the event instead. Raised on #241 by me and @ChristopherSchultz.

How to verify by hand

Defaults are retrylimit = 100 and lockoutperiod = 1. That period is in hours rather than minutes, per Duration.standardHours at LoginRequirementsChecker:99.

  1. POST /api/users/_login with a real username and a wrong password, 101 times. The 101st locks the account
  2. Send the same request for a nonexistent username, and for the locked account with its correct password. All three responses are byte-identical: FAIL, Login failed.
  3. GET /api/events?name=User%20account%20locked shows one event for the account, with username and locked until
  4. Time the three cases. They come within about 1% of each other instead of hundreds of times apart

Co-authored with Claude Code.

The lockout paths in DefaultUserController.authorizeUser named the
account and reported how long the lock had left to run, so an
unauthenticated caller could tell a valid username from an invalid one.
Both sites now return the generic "Incorrect username or password."
unless password.allowdetailedautherrors is set, which defaults to false.

Fixing the messages alone left a much louder signal. The password digest
is only reached for a valid, unlocked user: an unknown username never
enters the block and a locked account returns before it, so both skipped
600k PBKDF2 iterations. Measured on this branch with generic messages
enabled, n=250 per cohort, the medians were 230.90 ms for a valid
unlocked user against 0.49 ms locked and 0.76 ms unknown, with no
overlap between the distributions. One timed request classified any
username, whatever the response said.

Both miss paths now run a throwaway digest and discard the result. The
hash is generated once from the live digester so it follows the
configured algorithm and iteration count. Re-measured the same way, the
three cohorts land at 236.84, 237.74 and 237.05 ms, within a millisecond
of each other and inside the run-to-run noise.

Every path that skips the real digest now equalizes: an unknown
username, a locked account, an account that exists but has never had a
password set, and a failed pre-2.2 hash check. The last two are reachable
in normal use, since an admin can create an account before provisioning
it and legacy hashes survive upgrades.

This still does not make the paths identical. A valid user costs one
extra credentials query that a miss does not. That measured under a
millisecond against a 237 ms floor, so the practical oracle is closed
rather than the timing being constant.

Locked accounts are no longer cheap to reject, which gives up a property
lockout previously provided. An attacker gains nothing by locking an
account, since every username now costs the same, but the tradeoff is
deliberate rather than overlooked.

Adopts the approach from OpenIntegrationEngine#241 by Mitch Gaffigan, reworked for the
current source layout. That branch predates the move to src/main/java
and no longer applies. Takes the allowDetailedAuthErrors name Tony
Germano suggested on that thread, and sets the field explicitly in both
constructors as he asked, rather than widening the eleven-argument
constructor and churning its callers.

DefaultUserControllerAuthorizeUserTest covers the message behaviour:
a locked account is generic by default, reports detail when the property
is set, and is indistinguishable from an unknown username. The first and
third fail without this change. It binds mock ControllerFactory and
SqlConfig through Guice static injection, the way
DefaultHttpConfigurationTest already does, so no database is involved.
Nothing asserts the timing, which is measured against a running server.

Also renames PasswordRequirementsTests to PasswordRequirementsTest. The
build includes '**/*Test.class' only, so the plural-named class had
never been run; the rename puts its existing cases into the suite along
with the two new ones.

Closes OpenIntegrationEngine#240

Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Test Results

132 files  + 11  132 suites  +11   4m 7s ⏱️ + 2m 25s
803 tests +126  803 ✅ +126  0 💤 ±0  0 ❌ ±0 
929 runs  +240  923 ✅ +234  6 💤 +6  0 ❌ ±0 

Results for commit 90b7b95. ± Comparison against base commit c93b0f0.

♻️ This comment has been updated with latest results.

mgaffigan
mgaffigan previously approved these changes Sep 10, 2026

@mgaffigan mgaffigan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is unlikely to prevent timing side channel analysis, but it's better than nothing.

@pacmano1

Copy link
Copy Markdown
Contributor Author

I think this is unlikely to prevent timing side channel analysis, but it's better than nothing.

Far from perfect. Not really sure how to best do this by piling on more stuff, especially given the variation in machines and configs. Maybe change authroizeUser a lot or look into rate limiting, etc. At some threshold the behavior does have a pattern. And a single user name look up make the variation after a number of calls easier to see.

@mgaffigan

Copy link
Copy Markdown
Contributor

I think this is unlikely to prevent timing side channel analysis, but it's better than nothing.

Far from perfect. Not really sure how to best do this by piling on more stuff, especially given the variation in machines and configs. Maybe change authroizeUser a lot or look into rate limiting, etc. At some threshold the behavior does have a pattern. And a single user name look up make the variation after a number of calls easier to see.

Agreed. When a secure implementation is required, my answer remains "Don't try and do user auth; it's deceptively hard. Use an a separate OIDC IdP."

gibson9583
gibson9583 previously approved these changes Oct 6, 2026

@tonygermano tonygermano left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My issues before were

  • The system does not log an event to inform an admin that a user has been locked out, and all login failures look the same.
    • For example, if there are 5 attempts allowed, the 5th failure should produce a server event separate from the normal login failure indicating the user has been locked.
    • At a minimum, the 6th login failure should contain a reason that the account was locked if the user used the correct credentials.
  • The user panel and user edit screens do not indicate if a user is locked

If those things were taken care of, then I don't think we even need a mirth.properties option to keep the old messages; just change them to a generic authentication error message (i.e., do not say "invalid username or password", because that might not be true). Changing the messages without fixing at least some of the above I think creates an unmanageable environment for something that is really low risk in the first place.

Not a blocker for this PR, but should definitely be addressed:

  • There is no way over the REST API (and, therefore, from the admin client) for an admin to unlock a user once locked. These should not be linked, but changing the locked user's password does not even work as a workaround, because it doesn't reset the fields related to the lockout.

@pacmano1

pacmano1 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

@tonygermano Would this clear it?

  • When the strike that locks an account happens, fire a separate "user locked out" server event with the username and the unlock time.
  • A locked account that logs in with the correct password gets told it's locked and when it unlocks. A wrong password still gets the generic failure, so only someone who already has the password learns anything. It's the same one digest the locked path already runs, so timing stays equal.

The property stays for now. Lock status in the user panel and edit screens, plus a REST unlock, would be a separate issue and PR. Disabling a user, which doesn't exist at all today, would be its own issue too.

@tonygermano

Copy link
Copy Markdown
Member

@tonygermano Would this clear it?

  • When the strike that locks an account happens, fire a separate "user locked out" server event with the username and the unlock time.
  • A locked account that logs in with the correct password gets told it's locked and when it unlocks. A wrong password still gets the generic failure, so only someone who already has the password learns anything. It's the same one digest the locked path already runs, so timing stays equal.

The property stays for now. Lock status in the user panel and edit screens, plus a REST unlock, would be a separate issue and PR. Disabling a user, which doesn't exist at all today, would be its own issue too.

@pacmano1 I think just getting the server events worked out would be enough to drop the property and allow this to merge "always on."

The second suggestion you made should not be implemented, because it leaks other information similar to what this PR is trying to prevent. It allows an attacker to brute force passwords for a suspected account while the account is locked and be informed when they have hit the correct one. They would only need to wait for the unlock to happen and then sign in.

I don't know how difficult this would be to implement with the way things are currently set up, but it would be nice if a login failure event always had a failure reason (invalid credentials, account locked, etc...)

Every failed login now returns FAIL and "Login failed.", and there is no
property to bring the detailed messages back. The message no longer says
"Incorrect username or password.", which isn't true for a locked account
given the right password.

The failed login that locks an account dispatches a "User account
locked" server event (WARNING, FAILURE) with the username and the time
it unlocks, so an admin can still see the lock. It is dispatched from
DefaultUserController, so HTTP basic auth on the API gets it too.

Requested by tonygermano in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com>
@pacmano1

Copy link
Copy Markdown
Contributor Author

@tonygermano Done: the property is gone, every failed login says Login failed., and the attempt that locks an account logs a User account locked event with the username and unlock time. Agreed on the second idea, that's a password oracle while locked, so it's dropped. A failure reason on every Login event is #528, and lock status plus an unlock is #529.

Co-authored with Claude Code.

@mgaffigan

Copy link
Copy Markdown
Contributor

I don't love the equalizeAuthenticationTime - can we use the normal "branchless" model? Do all the same work in all cases and compare once at the end?

pacmano1 and others added 2 commits October 28, 2026 17:42
Rather than running a throwaway digest on each path that skipped the
real one, every attempt now does one credentials query (an unknown
username looks up an id no user has) and one full digest, against a
throwaway hash when there is no usable stored one, and the outcome is
decided once at the end. A pre-2.2 hash keeps its own cheap check
alongside the digest. Requested by mgaffigan in review.

This also covers the credentials query a valid user used to cost and an
unknown one didn't, so the ponytail note about it goes, along with the
double-checked locking and the logged-once warning around the old
helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com>
A locked account given its correct password must still fail, with its
strikes neither reset nor added to. No test covered it: every case used
a wrong password, so dropping the lock from the final check still passed
the whole class. Adds that case, and a plain successful login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com>

@mgaffigan mgaffigan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! Thanks

@pacmano1

Copy link
Copy Markdown
Contributor Author

Lotta effort here for arguably low value. Can't drive the car if I can't get to the road. Obligatory Car Analogy

@mgaffigan

Copy link
Copy Markdown
Contributor

Lotta effort here for arguably low value. Can't drive the car if I can't get to the road. Obligatory Car Analogy

100%. My position remains "remove user/pass auth and use OIDC". Defending it is a losing game.

@tonygermano tonygermano left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working through all of this. The lockout event and the generic message cover what I asked for in my earlier review. With lock status and unlock tracked in #529 and failure reasons in #528, I'm good with this going in "always on" without the property.

Three things I'd like done before merge:

1. Update the PR description to match the final code. Parts of it, such as the ponytail: marker and the extra credentials query, describe the approach from before 75d834f. It should also name the timing difference that is left. A wrong password for a real, unlocked account calls incrementStrikes(), which does a DB update and a read, and resetExpiredStrikes() does the same when old strikes have expired. An unknown username does neither. That's small next to the digest, but probably larger than the query difference the description mentions now. I don't think it can be closed without keeping strike rows for usernames that don't exist, so stating it is enough.

2. Squash into a single commit, with a commit message that describes the final version.

3. Add release note text to the PR description, clearly labeled as the release note. This changes what users see when a login fails and how admins find out about locked accounts. The "Behavior change" section is a good starting point.

Guice / #519: The new test uses Guice static injection, which #519 removes. Whichever merges second will need to update this test.

Claude checked that nothing else references FAIL_LOCKED_OUT (keeping the enum value is right, since plugins can still return it), that the pre-2.2 path still upgrades the hash on success, and that a locked account with the correct password fails without its strikes being reset or added to. Also good catch on the PasswordRequirementsTests rename: that class never ran.

Approving on the understanding that these get done before merge.


🤖 Drafted with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Username enumeration possible if lockout enabled

4 participants